전처리기 조건부 컴파일 — if 0으로 코드 끄기와 빌드 옵션 분기

NOTE

코드를 지우지 않고 통째로 꺼두거나, 빌드 환경에 따라 다른 코드를 컴파일에 포함시키는 전처리기 조건부 컴파일(#if/#ifdef) 패턴을 정리한다.

0. 왜 필요한가

리팩터링 중이거나 원인을 못 찾은 버그를 조사할 때, “이 블록을 잠깐 꺼보고 싶다”는 상황이 자주 생긴다. 지워버리면 되돌리기 번거롭고, /* */로 통째로 주석 처리하려니 그 안에 이미 다른 주석이 섞여 있으면 문제가 생긴다.

/* 이 함수를 통째로 주석 처리하고 싶은데... */
int calc(int x) {
    int y = x * 2;   /* 2배 */
    return y;
}

/* 2배 */라는 주석이 안에 있는 상태에서 함수 전체를 /* ... */로 감싸면, 첫 번째 */(2배 주석의 끝)에서 주석이 이미 끝나버려 그 뒤 코드가 그대로 컴파일된다. C의 블록 주석은 중첩(nesting)을 지원하지 않기 때문이다.

1. #if 0 ... #endif — 전처리 단계에서 코드를 통째로 들어낸다

#if 0
int calc(int x) {
    int y = x * 2;   /* 2배 — 이 안에 주석이 있어도 안전하다 */
    return y;
}
#endif

#if 0은 “이 조건은 항상 거짓”이라는 뜻이라, #endif까지의 내용은 컴파일러가 보기도 전에 전처리기가 통째로 잘라낸다. 그 안에 블록 주석이 몇 개가 들어있든, 심지어 문법이 깨진 코드가 들어있든 상관없다 — 전처리기 입장에선 그냥 텍스트 덩어리를 건너뛰는 것뿐이라 중첩 주석 문제 자체가 발생하지 않는다.

2. #if 1 / #if 0 스위치로 신·구 구현을 나란히 남겨두기

리팩터링 중 기존 로직을 바로 지우지 않고, 새 구현과 나란히 둔 채 스위치 하나로 바꿔가며 테스트하는 것도 흔한 실무 패턴이다.

int get_value(int key)
{
#if 1   /* 새 구현 — 정렬된 배열에서 이진 탐색 */
    return binary_search(g_table, g_table_len, key);
#else   /* 이전 구현 — 순차 탐색. 새 구현 검증 끝나면 통째로 삭제 예정 */
    int i;
    for (i = 0; i < g_table_len; i++) {
        if (g_table[i].key == key) {
            return g_table[i].value;
        }
    }
    return -1;
#endif
}

#if 1을 #if 0으로 한 글자만 바꾸면 즉시 이전 구현으로 돌아갈 수 있어서, 새 구현이 의심스러울 때 되돌리는 비용이 거의 0에 가깝다. 다만 이건 임시 안전장치이지 정착시킬 스타일은 아니다 — 검증이 끝나면 #else 이하는 통째로 지우는 게 맞다(자세한 내용: 5번 항목).

2-1. 실무 통신/임베디드 레거시의 특수 패턴: #if 1 변경 이력 박제(Feature Tagging)

통신망(5GC, NRF 등)이나 폐쇄망 장비의 C 코드베이스에서는 #else도 없이 #if 1 블록만 덩그러니 놓여 있는 코드가 흔히 발견된다:

#if 1 // ADD_PROF_SET_ID : R110 YM 2021.10.07
    st_sbi_string_list_t        st_nf_set_id_list;
#endif

무조건 컴파일되는 코드인데 왜 이렇게 해두었을까?

  1. 변경 범위(Scope)의 시각적 박제: 단순 주석 한 줄만 달면 어디서부터 어디까지가 이번 기능 추가분인지 모호하지만, #if 1 ~ #endif로 감싸두면 블록의 시작과 끝이 한눈에 드러난다.
  2. 긴급 롤백(Quick Feature Toggle): 상용망 배포 후 장애 발생 시, 코드를 복잡하게 지울 필요 없이 #if 1을 #if 0으로 단 한 글자만 바꾸어 즉시 해당 기능을 비활성화할 수 있다.
  3. 폐쇄망(에어갭) 환경의 한계 극복: 운영 서버 콘솔(vi)에서 긴급 패치나 트러블슈팅을 할 때, git blame/log를 쓸 수 없는 환경이 많다. 코드 자체에 **[기능명] : [릴리즈] [작업자] [작업일자]**를 남겨둠으로써 소스만으로 작업 맥락을 즉시 파악한다.
  4. 런타임 비용 0: 전처리기 단계에서 조건 평가 후 매크로 블록만 남기므로, 컴파일된 바이너리 크기나 실행 성능에는 어떠한 오버헤드도 없다.

레거시 부채화 주의

이 패턴은 긴급 대응과 추적에는 유리하지만, 기능이 안정화된 수년 뒤에도 코드를 정리(Refactoring)하지 않으면 불필요한 #if 1과 죽은 #if 0 블록이 코드베이스 전체에 누적되어 가독성을 크게 떨어뜨리는 기술 부채가 된다.

3. #ifdef / #if defined(...) — 빌드 환경에 따라 다른 코드 포함하기

#if 0이 “항상 끔”이라면, #ifdef/#if defined(...)는 빌드 시점에 정의된 매크로에 따라 켜고 끄는 조건부 컴파일이다. 디버그 전용 로그나 플랫폼별 코드 분기에 흔히 쓰인다.

#ifdef DEBUG_LOG
    fprintf(stderr, "[DEBUG] value=%d\n", value);
#endif
 
#if defined(PLATFORM_LINUX)
    epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);
#elif defined(PLATFORM_BSD)
    kevent(kq, NULL, 0, events, MAX_EVENTS, &ts);
#else
    #error "지원하지 않는 플랫폼입니다"
#endif

DEBUG_LOG나 PLATFORM_LINUX 같은 매크로는 소스 안에서 #define으로 직접 정의할 수도 있지만, 실무에서는 대개 컴파일 명령의 -D 옵션(gcc -DDEBUG_LOG ...)이나 빌드 설정 파일(Makefile의 CFLAGS)에서 주입한다 — 소스 코드를 건드리지 않고도 빌드 타깃마다 다른 옵션으로 같은 코드를 컴파일할 수 있다는 게 핵심 이점이다.

4. #error로 잘못된 조합을 컴파일 타임에 막기

3번 예제의 #error처럼, 지원하지 않는 매크로 조합이 들어오면 컴파일 자체를 그 자리에서 실패시키고 메시지를 남기는 것도 조건부 컴파일의 일부다. 런타임에 가서야 “이 플랫폼은 지원 안 함”을 알게 되는 것보다, 빌드 단계에서 바로 걸러지는 게 훨씬 안전하다.

5. 자주 하는 실수

  • #if 0으로 꺼둔 코드는 컴파일 검증을 받지 않는다는 걸 잊기 — 전처리기가 통째로 잘라내므로, 그 안의 코드가 참조하던 함수나 구조체 필드가 나중에 이름이 바뀌거나 삭제돼도 컴파일 에러가 나지 않는다. 한참 뒤에 #if 1로 다시 켜는 순간 무더기로 깨진 코드를 마주하게 된다.
  • #if 0 블록을 “나중에 정리하겠다”며 방치하기 — 임시로 끈 코드가 코드베이스에 계속 쌓이면, 파일을 열 때마다 “이게 왜 꺼져 있지, 지워도 되나”를 매번 판단해야 하는 부담이 생긴다. 검증이 끝났으면 지우고, 버전 관리 시스템(git 히스토리)이 필요하면 그쪽에서 되찾으면 된다 — 소스 안에 죽은 코드로 남겨둘 이유가 없다.
  • #endif 짝을 안 맞추기 — #if/#ifdef가 열렸는데 대응하는 #endif가 빠지면, 그 뒤 파일 전체(또는 include된 다음 파일들)가 의도와 다르게 통째로 켜지거나 꺼진 채로 컴파일된다. 중첩이 많아지면 #endif 옆에 // DEBUG_LOG처럼 어떤 조건의 끝인지 주석을 달아두는 게 실무 관례다.
  • 매크로 이름 오타를 컴파일러가 못 잡아주는 것 — #ifdef DEBUG_LOG라고 썼는데 실제 정의는 DEBUG_LOGS처럼 철자가 다르면, 경고나 에러 없이 그냥 “정의 안 됨”으로 조용히 처리돼 의도한 코드가 컴파일에서 빠진다. 매크로 이름은 헤더 한 곳에 모아 정의해서 오타 여지를 줄이는 게 좋다.

관련 문서